# 一、 执行上下文

js引擎在执行每个代码段(全局代码、函数体)前,浏览器已经做了一些“准备工作”(预编译):

全局代码

  1. 变量、函数表达式 —— 仅声明不赋值(默认赋值undefined)
  2. this —— 赋值
  3. 函数声明 —— 赋值

函数体

  1. 参数 —— 赋值
  2. arguments —— 赋值
  3. 自由变量的取值作用域 —— 赋值 (注:自由变量的取值是在函数定义时而非函数执行时确定的)

经过以上“准备工作”就生成了执行上下文(执行上下文环境),所以可以给执行上下文下个定义:

执行代码段之前(预编译阶段),对所有变量或函数声明进行赋值(有些默认赋值undefined),所形成的环境就是执行上下文环境。

上述两种代码段分别形成了全局执行上下文函数块执行上下文,当函数调用完成时,对应的执行上下文及其中的变量就会被销毁,再重新回到全局执行上下文环境,处于活动状态的执行上下文只有一个。而这种压栈出栈形成的数据栈就是执行上下文栈

# 二、 作用域

JS作用域分为:全局作用域、函数作用域、块作用域(ES6)。
函数作用域在函数定义时就确定了,而不是函数调用时。
作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

自由变量: 在 B 作用域中使用了变量 x,却未在 B 作用域中定义,那么 x 就称为 B 作用域的自由变量。
自由变量 x 应该到创建 B 作用域的那个作用域(假设为 A 作用域)中取值。若 A 作用域中也未定义变量 x,则继续到创建 A 作用的那个作用域中寻找,直至寻找到全局作用域,这就是作用域链

总结:

  1. 作用域是一个区域,用于隔离变量的有效范围。它是在定义时确定的,与是否调用无关。
  2. 执行上下文是预编译后,由所有变量和函数所组成的变量环境。它是在调用时确定的,不同的调用会产生不同的执行上下文。
  3. 沿着定义时形成的作用域包含关系,向上寻找自由变量取值的方式,就是作用域链。

注:沿着作用域链查找变量,但实际是到变量所在的执行上下文的 VO(变量对象)中取到变量的值。

# 三、 闭包

由于作用域链的存在,我们很容易理解下边的代码:

// 全局作用域 A
var a = 6;
function fn() {
  // 函数作用域 B
  var b = 8;
  console.log(a + b);
}
fn();   // 14

因为作用域 B 中的 a 是自由变量,所以要到定义 B 的 A 中寻找 a 的取值,即在正常作用域链中,函数内部可以取到函数外部的变量值,反之则不行。
那么,有没有办法让函数外部取到函数内部的变量呢? —— 这就是闭包所要实现的功能

闭包有两种情况:函数作为返回值,函数作为传参。 函数作为返回值

// 全局作用域 A
function fn() {
    // 函数作用域 B
    var a = 6;
    return function getA() {
        // 函数作用域 C
        console.log(a)
    }
}
var f1 = fn()
f1()    // 6

函数作为传参

var a = 6,
    getA = function() {
        // 函数作用域 B
        console.log(a)
    }
(function fn(f) {
    // 函数作用域 A
    var a = 8
    f()   // 6
})(getA)

要讲清楚闭包实现的原理,就要结合作用域和执行上下文来理解。

以第一种情况为例:

  1. 作用域是在定义时形成的,自由变量会沿作用域链寻找取值。
    定义时形成了作用域 A、B、C,且 a 是 C 中的自由变量,所以 a 可以沿着作用域链找到 6。

  2. 执行上下文是在调用时的预编译形成的,执行结束后就会销毁。

    • 当代码开始执行时,全局执行上下文入栈,处于活动状态;
    • 当执行 var f1 = fn() 时,fn执行上下文入栈,处于活动状态;
    • 执行完 var f1 = fn(),正常来说应该销毁fn执行上下文,但返回了一个函数getA,并引用了fn作用域下的fn执行上下文中的变量a,因为被引用那么a不能被销毁,所以a所在的fn执行上下文不能被销毁,依然存在于执行上下文栈中。这个被返回的函数getA就是闭包。(所以说闭包会增加内容开销,使用不慎会导致内存泄漏)
    • 当执行 f1() 时,即在执行闭包getA,getA执行上下文入栈,并处于活动状态,由上分析可知,getA中的自由变量a可沿作用域链找到fn执行上下文中的a的值,即为6。
    • 执行完 f1,getA执行上下文、fn执行上下文、全局执行上下文依次出栈并销毁。

# 四、综述

# 1. 什么是闭包?

  • 从功能上来讲,闭包可以简单理解成“可以读取其它函数内部变量的函数”;从形式上来讲,通常是“定义在一个函数内部的函数”或者“被作为参数传入另一个函数中的函数”。
  • 闭包的实现原理与作用域及执行上下文密切相关。正常情况下,当代码执行结束时相应的执行上下文就会销毁,但是当闭包中的自由变量引用了上游作用域中的变量时,上游作用域对应的执行上下文就无法销毁,那么当闭包函数执行时,就能获取到上游作用域对应执行上下文中的变量。
  • 闭包会使得函数中的变量被保存在内存中,增加内存消耗,一旦滥用,会造成网页性能问题。

# 2. 闭包会导致内存泄漏吗?

根据司徒正美的一篇文章 js闭包测试 (opens new window),结合个人理解得出以下结论:

  • 内存泄漏与闭包本身没有关系,而是因为不同浏览器的js引擎,对闭包情形下采用了不同的垃圾回收策略;

  • 举例来说,对于以下情形:

      function outer() {
          var a = {xxx}
          var b = 'bbb'
          return function() {
              console.log(b)      // 闭包中只使用了父作用域中的 b,直观理解未使用 a,执行var inner = outer()后,a 应该被垃圾回收机制回收
          }
      }
    
      var inner = outer()
    

    对于 IE8 (JScript) 及以下版本,执行 var inner = outer() 后,a 依然存在于内存中,当大量执行 var inner = outer() 后将暂用大量内存资源,甚至导致内存泄漏;
    对于 chrome (V8),执行 var inner = outer() 后,a 不存在了。

  • 针对 IE8 (JScript) 及以下版本因垃圾回收机制导致的内存泄漏,可采取以下措施:

      function outer() {
          var a = {xxx}
          var b = 'bbb'
    
          // 退出函数之前,将闭包中不使用的变量删除
          a = null
    
          return function() {
              console.log(b)
          }
      }
    
      var inner = outer()
    

# 3. 闭包的应用场景?

  1. 封装私有变量

     function f1() {
         var sum = 0;
         var obj = {
             inc:function () {
                 sum++;
                 return sum;
             }
         };
         return obj;
     }
     var result = f1();
     console.log(result.inc());  //1
     console.log(result.inc());  //2
     console.log(result.inc());  //3
    
  2. setTimeout 或 事件回调 传参数
    原生的setTimeout传递的第一个函数不能带参数,通过闭包可以实现传参效果

     function f1(a) {
         function f2() {     // 这是实际想传给setTimeout的函数
             console.log(a);
         }
         return f2;
     }
     var fun = f1(1);
     setTimeout(fun, 1000);  // setTimeout不支持直接传参 f2(1)
    

    与setTimeout类似,原生的addEventListener传递的回调函数不能带参数,通过闭包可以实现传参效果

     function changeSize(size){
         return function(){
             document.body.style.fontSize = size + 'px';
         };
     }
    
     var size12 = changeSize(12);
     var size14 = changeSize(20);
     var size16 = changeSize(30);
    
     document.getElementById('size-12').addEventListener(size12)
     document.getElementById('size-20').addEventListener(size20)
     document.getElementById('size-30').addEventListener(size30)
    
  3. 事件防抖中计时

    事件触发n秒后再执行回调,如果n秒内再次被触发,则重新计时。
    由于需要一个变量保存计时,为了保持全局纯净,可以借助闭包来实现。

     // fn 需要防抖的函数
     // delay 需要延迟触发的时间
     function debounce(fn, delay) {
         let timer = null
         return function() {
             if (timer) {
                 clearTimeout(timer)
             }
             timer = setTimerout(fn, delay)
         }
     }
    
     // 使用
     function fn() {...}
     let fun = debounce(fn, 100)
     document.querySelector('#btn').addEventListener(fun)
    
最后更新时间: 3/28/2021, 9:04:52 PM